[免杀学习]杀毒软件扫描浅析(上)
本篇文章站在杀毒软件的角度,来简单梳理一下杀软扫描常用的4种方式,分别是内存内容扫描,内存属性扫描,注册表监控,二进制内容扫描
其中比较难的是内存扫描,但是还是放在上篇写了,呃呃
本篇文章均以C++为编程语言
内存内容扫描
源代码:
1 |
|
这个程序的功能就是从虚拟内存地址的最开始0x00000开始寻找能够读取内容的地址,然后把第一个能够读取到的内存页面内容打印到控制台
逐个讲解
STARTUPINFO si;
STARTUPINFO是一个结构体,用以指定新进程的主窗口特性,比如窗口的位置,大小,标题,颜色,具体不需要了解,用来做CreateProcess的参数
PROCESS_INFORMATION pi;
PROCESS_INFORMATION是一个结构体,用来存储新创建的进程以及其线程的信息
1 | typedef struct _PROCESS_INFORMATION { |
HANDLE hProcess = NULL;
HANDLE是一个void型指针,也就是可以指向任意类型的指针,有很多void型指针为了规范其命名与用途就针对不同作用而设置了不同的种类名称,像hProcess就是一个句柄变量,里面可以存储对进程,线程,文件的引用
ZeroMemory(&si, sizeof(si));
ZeroMemory是一个宏定义,本质上就是调用memset将结构体s1的那片内存清空为0
1 | //宏定义过程 |
si.cb = sizeof(si);
cb是STARTUPINFO的第一个属性成员,表示STARTUPINFO结构体要占用多大的空间,这里给他初始化为si本身的大小,这是为了让 CreateProcess 函数知道 si 结构体的版本。
ZeroMemory(&pi, sizeof(pi));
将pi结构体占用的内存区域清空为0
CreateProcess(“test.exe”, NULL, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)
创建一个执行test.exe的新进程,si是执行这个进程的主窗口,因为大小值是0,所以不会有窗口
1 | CreateProcessA( |
byte* readtemp = new byte[256 * 16];
定义一个字节数组,用来接受读取到的内存数据,大小为4kb
hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pi.dwProcessId);
OpenProcess函数用来获取一个已经存在的进程的句柄,PROCESS_ALL_ACCESS代表要获取所有可用的进程权限,pi.dwProcessId是进程标识符
ReadProcessMemory(hProcess, (LPCVOID)i, readtemp, 0x1000, &dwNumberOfBytesRead)
ReadProcessMemory从指定的进程中读取固定大小的内容到一个缓冲区里,hProcess就是要读取的进程句柄,i是读取的基地址,0x1000代表读取字节的大小,dwNumberOfBytesRead是实际读取到缓冲区的字节大小
所以这个程序就是实现了一个读取test.exe在运行的内存数据的功能,具体来说,先创建一个可执行文件(我为了结果稳定就写了个死循环这样可以保证在找到有效内存之前程序不会退出),然后从0x0000地址开始,每次加0x10,直到找到有效内存区域,然后读出来指定的大小数据
内存属性扫描
源代码:
1 |
|
功能就是扫描从用户模式下可以使用的最低虚拟内存地址到最高虚拟内存地址之间的内存属性,具体效果图如下
个人认为这里是最难得,所以会把每个api函数都讲的很仔细
逐行讲解
1 | SYSTEM_INFO sysinfo; |
SYSTEM_INFO是一个结构体,里面包含了关于这台计算机的系统信息,比如处理器,内存,页面大小等
这里我们主要用来通过他来获取最低的有效的基地址
1 | typedef struct _SYSTEM_INFO { |
dwPageSize补充:
页面是操作系统管理内存的基本单位,它是一块固定大小的连续内存空间。页面和内存的关系是,操作系统把物理内存(RAM)分成多个页面,然后把这些页面映射到虚拟内存(Virtual Memory)中,供进程使用。虚拟内存是操作系统提供给进程的一种抽象的内存空间,它可以大于物理内存的大小,因为操作系统可以把不常用的页面交换到磁盘上,从而节省物理内存
lpMinimumApplicationAddress lpMaximumApplicationAddress补充:
lpMinimumApplicationAddress和lpMaximumApplicationAddress分别指向了应用程序和DLL可以访问的最低和最高的虚拟地址。这两个地址是由操作系统决定的,它们可能因为操作系统版本、配置、硬件等因素而有所不同。一般来说,这两个地址之间的虚拟内存空间是进程可以使用的用户模式地址空间,而低于或高于这两个地址的虚拟内存空间是保留给操作系统使用的内核模式地址空间
dwActiveProcessorMask补充:
dwActiveProcessorMask是一个32位的无符号整数,它表示了系统中配置的处理器集合。每一位对应一个处理器,如果这一位是1,那么表示这个处理器是可用的;如果这一位是0,那么表示这个处理器是不可用的。例如,如果dwActiveProcessorMask的值是0x00000003,那么表示系统中有两个处理器,分别是处理器0和处理器1,它们都是可用的;如果dwActiveProcessorMask的值是0x00000005,那么表示系统中有三个处理器,分别是处理器0,处理器1和处理器2,其中处理器0和处理器2是可用的,而处理器1是不可用的(2^0+0+2^2=5)。
dwAllocationGranularity补充:
虚拟内存分配的起始地址的粒度,用人话说就是分配的虚拟内存的地址必须是这个值的整数倍,
假设dwAllocationGranularity的值是64KB,也就是65536字节,那么虚拟内存的起始地址必须是65536的整数倍,比如0x00000000, 0x00010000, 0x00020000等。这样做的好处是可以保证虚拟内存空间的连续性和一致性,也就是说,每次分配或释放虚拟内存时,都会产生一个大小为64KB的整数倍的连续空间,而不会出现小于64KB或者不对齐的空间。这样就可以避免虚拟内存空间的碎片化,也就是说,避免出现很多小块的未使用的虚拟内存空间,这样就可以节省虚拟内存空间,并且减少操作系统管理虚拟内存空间的开销。
那么,为什么在内存对齐中要求”起始地址要被其所占字节大小整除”又要求“起始地址要被粒度整除”,到底听谁的?
其实这两个要求是不矛盾的,因为dwAllocationGranularity的值通常是一个2的幂,比如64KB,也就是2的16次方。这样的话,只要一个地址能够被dwAllocationGranularity整除,那么它也一定能够被它占用的字节数整除,只要它占用的字节数也是一个2的幂。例如,如果一个地址是0x00010000,那么它能够被64KB整除,也能够被4字节或者8字节整除,因为4和8都是2的幂。所以,如果你按照dwAllocationGranularity来分配虚拟内存的起始地址,那么你也就满足了内存对齐的要求
1 | MEMORY_BASIC_INFORMATION mbi = { 0 }; |
这个结构体是用来描述一段进程的虚拟地址空间的内存区域信息
1 | typedef struct _MEMORY_BASIC_INFORMATION { |
对于BaseAddress和AllocationBase补充:
对于这两个成员我一开始没明白他们的区别在哪,在看了这篇[帖子](windows - What is difference between BaseAddress and AllocationBase in MEMORY_BASIC_INFORMATION struct? - Stack Overflow)之后,好像明白了一些东西
结合Answer的代码和注解来讲一下我自己的理解,
1 |
|
这个程序的流程大致如下:
1.用VirtualAlloc开辟一片大小为65535个字节,也就是0x10000大小的内存区域,将其状态改为提交,并且可读可写可执行,mem指针指向起始地址,作者的mem指向的是00ED0000这个地址(和计算机系统环境有关,不用细究)
2.调用showmem函数,用VirtualQuery打印内存区域的信息,AllocationBase和BaseAddress暂且按下不表,RegionSize就是前面提到的相同属性相同状态的一片内存空间的大小,Protect是这片区域的保护属性
3.继续执行主函数的剩余流程,用VirtualProtect改变起始地址后的第4096,也就是0x1000个地址的保护属性,一直改变了4096个字节,也就是0x1000个字节
4.调用3次showmem函数,打印3段内存区域的信息,分别是
00ED0000(起始地址)——————–00ED1000(起始地址+0x1000)
00ED1000(起始地址+0x1000)——————–00ED2000(起始地址+0x1000+0x1000)
这里小小吐槽一下,被改变的区域的地址偏移量和改变区域的大小最好不要相同,不然容易看混
00ED2000(起始地址+0x1000+0x1000)——————–00ED2000(起始地址+0x1000+0xE000)
5.最终结果展示
1 | Initial allocation: |
可以看出,被我们用VirtualProtect改变保护属性的第二段内存区域,就像一把大刀将原本完整的一段连续的内存给“劈”成了3段
我们回过头看看AllocationBase的定义:
由VirtualAlloc函数分配的一段内存空间的起始地址的指针。
可以发现确实是这样的,不论你怎么分这片内存,每个小内存区域的AllocationBase,也就是源头都是VirtualAlloc函数分配内存区域的起始地址00ED0000
再来看看BaseAddress的定义:
A pointer to the base address of the region of pages.
翻译过来就是
指向页面区域基地址的指针
也就是说,在整个大的内存区域中,一开始,所有内存区域的保护属性都是一样的(PAGE_READWRITE),但是突然从某个地方(00ED1000)开始,内存的保护属性被修改为(PAGE_NOACCESS),而为了能精确的管理整个内存区域,不得不把这个“异端”给单独划分出来,而划分的标识就是BaseAddress
但这个“异端”只占了整个内存的一部分,跟在这个区域的后面的第三段内存区域保护属性和这个“异端”又不一样了,所以第三段区域的BaseAddress就得改为第二段内存区域结束后的开始地址
继续回归正题
ZeroMemory(&sysinfo, sizeof(SYSTEM_INFO));
初始化sysinfo
GetSystemInfo(&sysinfo)
获取当前计算机系统环境的信息,并写入sysinfo结构体的对应成员中
pAddress = (PBYTE)sysinfo.lpMinimumApplicationAddress;
获取用户能够使用的系统最低的虚拟地址
printf("------------------------------------------------------------------------ \n"); printf("开始地址 \t 结束地址 \t\t 大小 \t 状态 \t 内存类型 \n"); printf("------------------------------------------------------------------------ \n");
准备工作做完了,开始进行扫描和打印的步骤
while (pAddress < (PBYTE)sysinfo.lpMaximumApplicationAddress)
从lpMinimumApplicationAddress(能够使用的最低内存地址)开始扫描,一直扫描到lpMaximumApplicationAddress(能够使用的最高内存地址)
1 | ZeroMemory(&mbi, sizeof(MEMORY_BASIC_INFORMATION)); |
初始化每个连续内存页面的MEMORY_BASIC_INFORMATION结构体
1 | stSize = VirtualQueryEx(hProc, pAddress, &mbi, sizeof(MEMORY_BASIC_INFORMATION)); |
获取hProc这个进程的内存信息,从地址pAddress开始读,把这段读到的内存信息填入mbi结构体
这个函数执行成功会返回读入到缓冲区的内存信息字节数,失败则返回0
if (stSize == 0)
{
pAddress += sysinfo.dwPageSize;//跳过无效页面(4KB)
continue;
}
如果返回0,说明执行失败,可能因为是权限或者其他意外原因,遇到这种情况就直接跳过一个页面大小
1 | printf("0x%08X \t 0x%08X \t %8d K \t ", mbi.BaseAddress, |
打印这片连续内存页面的BaseAddress,结束地址,然后把区域大小字节数除以2^10换算成KB
1 | switch (mbi.State) |
根据mbi的State的常量值判断内存区域的状态,再简单回顾一下,空闲就是无效内存,需要先申请;保留就是申请过了,但是还没有实际分配物理内存,提交就是分配完了物理内存可以直接执行使用(初始化为0)
1 | switch (mbi.Type) |
根据常量值对内存类型进行判断:
- MEM_PRIVATE: 这个页面区域是私有的,也就是说它只能被分配它的进程访问。这个页面区域是通过VirtualAlloc函数分配的,它不与任何文件或映射对象关联。
- MEM_MAPPED: 这个页面区域是映射的,也就是说它与一个文件或映射对象关联。这个页面区域是通过MapViewOfFile函数或者它的扩展版本创建的,它可以被多个进程共享访问。
- MEM_IMAGE: 这个页面区域是镜像的,也就是说它与一个可执行文件或动态链接库关联。这个页面区域是通过LoadLibrary函数或者它的扩展版本创建的,它可以被多个进程共享访问。
1 | if (mbi.Protect == 0) |
对内存保护属性的判断,若为0就不是有效内存区域
预定义常量 | 权限 |
---|---|
PAGE_NOACCESS | 没有任何权限 |
PAGE_EXECUTE | 可执行 |
PAGE_EXECUTE_READ | 可读可执行 |
PAGE_EXECUTE_READWRITE | 可读可写可执行 |
PAGE_READONLY | 只读 |
PAGE_READWRITE | 可读可写(直接修改原始页面) |
PAGE_WRITECOPY | 可读,写时复制,修改页面后会新开一个副本,不更改原始页面而是把更改后的结果写到这个新开副本里 |
PAGE_EXECUTE_WRITECOPY | 可执行,可读,写时复制,修改页面后会新开一个副本,不更改原始页面而是把更改后的结果写到这个新开副本里 |
PAGE_GUAR | 页面保护修饰符 |
0 | 未提交的内存页面,无法访问 |
PAGE_GUAR用来标识一个保护页
保护页就相当于自己当一个蜜罐,如果有程序意外访问到自己,这个保护页自己就会抛出一个异常来达到防止PWN攻击或者其他栈溢出错误,这是一种防御性的内存管理技术。它可以帮助程序检测和处理一些潜在的内存错误或攻击
Main函数
1 | HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, GetCurrentProcessId()); |
获取当前进程的进程句柄,GetCurrentProcessId()函数返回当前进程的标识符
1 | ScanProcessMemory(hProc); |
执行内存属性扫描函数
1 | CloseHandle(hProc); |
关闭进程句柄
流程总结:
1.获取当前进程的句柄,也就是我们写的cpp程序,自己获取自己的句柄
2.进入ScanProcessMemory方法,执行内存属性扫描流程
3.先做准备工作,获取当前系统的可用虚拟地址空间范围(GetSystemInfo(&sysinfo);)
4.通过MEMORY_BASIC_INFORMATION获取每一个连续的内存页面,并将其起始地址,结束地址,状态,类型打印到控制台
5.把用户模式下可用的所有内存空间遍历完后,退出函数,返回主函数,关闭进程句柄